RestController (Test)

 

TestContoller.java 생성 및 작성

  • main/java/com/wise > 새로 만들기 > Java 클래스

    Spring Boot 구축_01.png

 

  • TestController 생성

    Spring Boot 구축_02.png

 

  • 작성
package com.wise;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/test")
public class TestController {
    @GetMapping("/string")
    public String test() {
        return "yes";
    }

    @GetMapping("/json")
    public Map<String, Object> testJson() {
        Map<String, Object> testMap = new HashMap<>();

        testMap.put("A", "1");
        testMap.put("B", 2);

        return testMap;
    }
}

 

실행

Spring Boot 구축_03.png

 

Web 확인

  • http://localhost:8080/test/string

    Spring Boot 구축_04.png

  • http://localhost:8080/test/json

    Spring Boot 구축_05.png

 

정적 HTML 표시

HTML 생성 및 작성

  • main/resources/static > 새로 만들기 > HTML 파일

    Spring Boot 구축_06.png

 

  • sample.html 생성

    Spring Boot 구축_07.png

 

  • 작성

    Spring Boot 구축_08.png

 

Web 확인

  • http://localhost:8080/sample.html

    Spring Boot 구축_09.png

 

Controller를 이용한 View 이동

ViewController.java 생성 및 작성

  • main/java/com/wise > module.common.controller 패키지 생성
    Spring Boot 구축_10.png

 

  • Spring Boot 구축_11.png

 

  • Spring Boot 구축_12.png

 

  • main/java/com/wise/module/common/controller/ViewController.java 생성
package com.wise.module.common.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/v")
public class ViewController {

    @GetMapping("/{view}")
    public String view(@PathVariable("view") String view) {
        return view;
    }
}

 

HTML 생성 및 작성

  • main/resources/templates > 새로 만들기 > HTML 파일

    Spring Boot 구축_13.png

 

  • sample.html 생성

    Spring Boot 구축_07.png

 

  • 작성

    Spring Boot 구축_08.png

 

Web 확인

  • http://localhost:8080/v/sample

    Spring Boot 구축_14.png

 

환경설정 파일 형식 변경

  • application.properties 파일을 yml 파일로 변경

  • yml 형식이 가독성이 좋기 때문에 사용

    Spring Boot 구축_15.png

 

  • Spring Boot 구축_16.png

 

  • 내용 변경

    Spring Boot 구축_17.png

 

  • Spring Boot 구축_18.png

 

서버 안전 종료

  • 서버 종료 요청 시 더 이상의 작업은 받지 않고, 처리 중인 작업 완료 후 종료
  • application.yml 수정
spring:
  lifecycle:
    timeout-per-shutdown-phase: 1m # 서버 안전 종료 타임아웃 1 minute, 20초는 20s

server:
  shutdown: graceful # 서버 안전 종료

Spring Boot 구축_71.png

 

Local, Dev, Prod 환경설정 분리

파일 생성

  • main/resources/application-local.yml

  • main/resources/application-dev.yml

  • main/resources/application-prod.yml

    Spring Boot 구축_19.png

 

application.yml에 profile 작성

spring:
  application:
    name: Wise
  profiles:
    active: local
#    active: dev
#    active: prod

Spring Boot 구축_20.png

 

application-local.yml 작성

  • 서버 포트 설정해보기

    Spring Boot 구축_29.png

 

WiseApplication.java (SpringBootApplication) 수정

@PropertySource("classpath:application-${spring.profiles.active}.yml")	// local, dev, prod

Spring Boot 구축_21.png

 

테스트

  • TestController.java 수정
import org.springframework.beans.factory.annotation.Value;

// 중략

    @Value("${spring.profiles.active}")
    private String profile;

    @GetMapping("/profile")
    public String profile() {
        return profile;
    }

Spring Boot 구축_22.png

Spring Boot 구축_23.png

 

로컬 SSL 설정

Info
  • 로컬에 https 접속이 필요한 경우에만 설정
  • 추후 운영서버에 SSL 설정 시 'keystore 파일 생성' 항목 외에는 동일

 

keystore 파일 생성

  • 관리자 권한으로 cmd 실행
> cd C:\Wise\jdk-22\bin
> keytool -genkey -alias spring -storetype PKCS12 -keyalg RSA -keysize 2048 -keystore keystore.p12 -validity 4000
> Enter keystore password : t2llocal

Spring Boot 구축_48.png

Spring Boot 구축_49.png

 

keystore.p12 파일을 프로젝트 resources 폴더로 이동

Spring Boot 구축_50.png

 

application-local.yml 수정

server:
  port: 8443 # 실제 운영 서버에서는 443 (application-prod.yml에서는 port: 443 설정)
  ssl:
    key-store: classpath:keystore.p12
    key-store-type: PKCS12
    key-store-password: t2llocal
    key-alias: spring

Spring Boot 구축_51.png

 

https://localhost:8443/ 접속

Spring Boot 구축_52.png

 

고급 -> localhost (안전하지 않음)(으)로 계속하기

Spring Boot 구축_53.png

 

http to https redirect

  • SSL 설정 후 http로 접속 시 에러 발생
  • http로 접속시 https로 redirect

 

application-local.yml 수정

http.port: 8080 # 실제 운영 서버에서는 80 (application-prod.yml에서는 port: 80 설정)

Spring Boot 구축_54.png

 

ServletConfiguration.java 생성 및 작성

  • main/java/com/wise/config/ServletConfiguration.java
package com.wise.config;

import io.undertow.servlet.api.SecurityConstraint;
import io.undertow.servlet.api.SecurityInfo;
import io.undertow.servlet.api.TransportGuaranteeType;
import io.undertow.servlet.api.WebResourceCollection;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.embedded.undertow.UndertowBuilderCustomizer;
import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ServletConfiguration {

    @Value("${http.port}")
    private int httpPort;

    @Value("${server.port}")
    private int sslPort;

    @Bean
    public ServletWebServerFactory serverFactory() {
        UndertowServletWebServerFactory factory = new UndertowServletWebServerFactory();

        factory.addBuilderCustomizers((UndertowBuilderCustomizer) builder -> {
            builder.addHttpListener(httpPort, "0.0.0.0");
        });

        factory.addDeploymentInfoCustomizers(deploymentInfo -> {
            deploymentInfo.addSecurityConstraint(
                            new SecurityConstraint()
                                    .addWebResourceCollection(new WebResourceCollection().addUrlPattern("/*"))
                                    .setTransportGuaranteeType(TransportGuaranteeType.CONFIDENTIAL)
                                    .setEmptyRoleSemantic(SecurityInfo.EmptyRoleSemantic.PERMIT))
                    .setConfidentialPortManager(exchange -> sslPort);
        });

        return factory;
    }
}

 

http://localhost:8080 접속

Spring Boot 구축_55.png

 

https://localhost:8443/ 리다이렉트 확인

Spring Boot 구축_56.png

 

HTTP/2 설정

Info

HTTP/2 프로토콜은 SSL(https) 필수

 

기존 HTTP 통신 확인

Spring Boot 구축_57.png

 

application-local.yml 수정

server:
  http2:
    enabled: true

Spring Boot 구축_58.png

 

HTTP/2 통신 확인

Spring Boot 구축_59.png

 

내장 Tomcat -> 내장 Undertow로 웹서버 변경

Info

내장 Tomcat과 외장 Tomcat의 성능 차이는 없다.
Tomcat보다 Undertow가 성능이 좋다.

 

  • build.gradle 수정
dependencies {
    implementation('org.springframework.boot:spring-boot-starter-web') {
        exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
    }
    implementation 'org.springframework.boot:spring-boot-starter-undertow'
    
    // 기타 의존성들...
}

 

  • 실행 후 로그에 Undertow로 변경 확인

    Spring Boot 구축_24.png

 

MariaDB / Mybatis 연동

Dependency 추가

  • build.gradle 수정
	implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
	implementation 'org.mariadb.jdbc:mariadb-java-client'
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'

Spring Boot 구축_26.png

 

Hikari 설정

  • main/java/com/wise/config/DatabaseConfiguration.java 생성

    Spring Boot 구축_27.png

package com.wise.config;

import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

@Configuration
@PropertySource("classpath:application-${spring.profiles.active}.yml")	// local, dev, prod
public class DatabaseConfiguration {
    @Autowired
    private ApplicationContext applicationContext;

    @Bean
    @ConfigurationProperties(prefix="spring.datasource.hikari")
    public HikariConfig hikariConfig() {
        return new HikariConfig();
    }

    @Bean
    public DataSource dataSource() throws Exception{
        DataSource dataSource = new HikariDataSource(hikariConfig());
//        System.out.println(dataSource.toString());
        return dataSource;
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception{
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);
        sqlSessionFactoryBean.setMapperLocations(applicationContext.getResources("classpath:mybatis/**/*.xml"));
        sqlSessionFactoryBean.setTypeAliasesPackage("com.wise.*");

        return sqlSessionFactoryBean.getObject();
    }

    @Bean
    public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory){
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

 

properties 추가

  • application-local.yml
  • application-dev.yml
  • application-prod.yml
spring:
  datasource:
    hikari:
      driver-class-name: org.mariadb.jdbc.Driver
      jdbc-url: jdbc:mariadb://localhost:3306/wise?characterEncoding=UTF-8&serverTimezone=UTC&allowMultiQueries=true
      username: root
      password: root
      connection-test-query: SELECT 1

Spring Boot 구축_30.png

 

mybatis 폴더 생성

  • main/resources/mybatis

    Spring Boot 구축_28.png

 

mapper 생성

  • main/resources/mybatis/test/TestMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="test.Test">
    <select id="selectTest" parameterType="HashMap" resultType="HashMap">
        SELECT  user_id  
            ,   user_name  
            ,   #{param1}  
        FROM    tcm_user_mst
    </select>
</mapper>

 

dao 생성

  • main/java/com/wise/TestDAO.java
package com.wise;

import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.stereotype.Repository;

@RequiredArgsConstructor
@Repository
public class TestDAO {
    private final SqlSessionTemplate sqlSession;

    public List<Map<String, Object>> selectTest(Map<String, Object> params) {
        return sqlSession.selectList("test.Test.selectTest", params);
    }
}

 

service 생성

  • main/java/com/wise/TestService.java
package com.wise;

import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class TestService {
    private final TestDAO testDAO;

    public List<Map<String, Object>> selectTest(Map<String, Object> params) {
        params.put("param1", "AAA");
        return testDAO.selectTest(params);
    }
}

 

controller 수정

  • main/java/com/wise/TestController.java
package com.wise;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RequiredArgsConstructor
@RestController
@RequestMapping("/test")
public class TestController {
    private final TestService testService;

    @GetMapping("/mybatis")
    public Map<String, Object> mybatis() {
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("result", testService.selectTest(new HashMap<>()));
        return resultMap;
    }
}

 

Web 확인

  • http://localhost:8080/test/mybatis

    Spring Boot 구축_31.png

 

로그 설정

application.yml 수정

  • 아래 설정을 추가해야 콘솔에 색상이 표시된다.
spring:
  output:
    ansi:
      enabled: always

Spring Boot 구축_70.png

 

logback-spring.xml 생성 및 작성

  • main/resources/logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- 60초마다 설정 파일의 변경을 확인 하여 변경시 갱신 -->
<configuration scan="true" scanPeriod="60 seconds">
    <!-- 색상 설정 -->
    <!-- %clr(PATTERN){faint} -->
    <!-- blue, cyan, faint, green, magenta, red, yellow-->
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />

    <!-- log file path -->
    <property name="LOG_PATH" value="/logs"/>
    <!-- log file name -->
    <property name="LOG_FILE_NAME" value="info"/>
    <!-- err log file name -->
    <property name="ERR_LOG_FILE_NAME" value="error"/>
    <!-- pattern -->
    <property name="LOG_PATTERN" value="%clr([%-5level]) %clr(%d{yy-MM-dd HH:mm:ss}){magenta} [%thread] %clr([%logger{0}:%line]){cyan} - %msg%n"/>

    <!-- Console Appender -->
    <appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <!-- File Appender -->
    <appender name="File" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 파일경로 설정 -->
        <!--<file>${LOG_PATH}/${LOG_FILE_NAME}.log</file>-->
        <fileNamePattern>${LOG_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log</fileNamePattern>

        <!-- 출력패턴 설정-->
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>

        <!-- Rolling 정책 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- .gz,.zip 등을 넣으면 자동 일자별 로그파일 압축 -->
            <fileNamePattern>${LOG_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}_%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <!-- 파일당 최고 용량 kb, mb, gb -->
                <maxFileSize>10MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!-- 일자별 로그파일 최대 보관주기(~일), 해당 설정일 이상된 파일은 자동으로 제거-->
            <maxHistory>30</maxHistory>
            <!--<MinIndex>1</MinIndex>
            <MaxIndex>10</MaxIndex>-->
        </rollingPolicy>
    </appender>

    <!-- 에러의 경우 파일에 로그 처리 -->
    <appender name="Error" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>error</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
        <file>${LOG_PATH}/${ERR_LOG_FILE_NAME}.log</file>
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>
        <!-- Rolling 정책 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- .gz,.zip 등을 넣으면 자동 일자별 로그파일 압축 -->
            <fileNamePattern>${LOG_PATH}/${ERR_LOG_FILE_NAME}.%d{yyyy-MM-dd}_%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <!-- 파일당 최고 용량 kb, mb, gb -->
                <maxFileSize>10MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!-- 일자별 로그파일 최대 보관주기(~일), 해당 설정일 이상된 파일은 자동으로 제거-->
            <maxHistory>60</maxHistory>
        </rollingPolicy>
    </appender>

    <!-- root레벨 설정 -->
    <root level="info">
        <appender-ref ref="Console"/>
        <appender-ref ref="File"/>
        <appender-ref ref="Error"/>
    </root>

    <!-- 특정패키지 로깅레벨 설정 -->
    <!-- 	<logger name="org.apache.ibatis" level="DEBUG" additivity="false"> -->
    <!-- 		<appender-ref ref="CONSOLE"/> -->
    <!-- 		<appender-ref ref="FILE"/> -->
    <!-- 		<appender-ref ref="Error"/> -->
    <!-- 	</logger> -->
</configuration>

 

로그 출력 확인

  • 상단에 @Slf4j 선언
  • log.info
  • log.debug
  • log.warn
  • log.error

Spring Boot 구축_33.png

Info

log.info("test {}", test) 처럼 {}를 이용하여 치환해서 사용해야한다.
log.info("test {}"+test) 처럼 + 연산자를 사용하면 성능 이슈가 있다.

 

로그 폴더 및 파일 생성 확인

Spring Boot 구축_34.png

 

DB 로그 설정

Dependency 추가

  • build.gradle 수정
implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'

Spring Boot 구축_35.png

 

DB 연동 정보 수정

  • application-local.yml
  • application-dev.yml
  • application-prod.yml
# driver-class-name: org.mariadb.jdbc.Driver
# jdbc-url: jdbc:mariadb://localhost:3306/wise?characterEncoding=UTF-8&serverTimezone=UTC&allowMultiQueries=true

driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy  
jdbc-url: jdbc:log4jdbc:mariadb://localhost:3306/wise?characterEncoding=UTF-8&serverTimezone=UTC&allowMultiQueries=true

Spring Boot 구축_36.png

 

log4jdbc.log4j2.properties 생성 및 작성

  • main/resources/log4jdbc.log4j2.properties
log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator
log4jdbc.dump.sql.maxlinelength=0

Spring Boot 구축_37.png

 

logback-spring.xml 수정

  • main/resources/logback-spring.xml
<!-- log4jdbc 옵션 설정 -->
<logger name="jdbc" level="OFF"/>
<!-- 커넥션 open close 이벤트를 로그로 남긴다. -->
<logger name="jdbc.connection" level="OFF"/>
<!-- SQL문만을 로그로 남기며, PreparedStatement일 경우 관련된 argument 값으로 대체된 SQL문이 보여진다. -->
<logger name="jdbc.sqlonly" level="OFF"/>
<!-- SQL문과 해당 SQL을 실행시키는데 수행된 시간 정보(milliseconds)를 포함한다. -->
<logger name="jdbc.sqltiming" level="DEBUG"/>
<!-- ResultSet을 제외한 모든 JDBC 호출 정보를 로그로 남긴다. 많은 양의 로그가 생성되므로 특별히 JDBC 문제를 추적해야 할 필요가 있는 경우를 제외하고는 사용을 권장하지 않는다. -->
<logger name="jdbc.audit" level="OFF"/>
<!-- ResultSet을 포함한 모든 JDBC 호출 정보를 로그로 남기므로 매우 방대한 양의 로그가 생성된다. -->
<logger name="jdbc.resultset" level="OFF"/>
<!-- SQL 결과 조회된 데이터의 table을 로그로 남긴다. -->
<logger name="jdbc.resultsettable" level="OFF"/>

Spring Boot 구축_38.png

 

로그 출력 확인

Spring Boot 구축_39.png

 

Mybatis 특정 쿼리 로그에서 제외하기

  • main/java/com/wise/config/LogbackFilter.java 생성
package com.wise.config;

import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.filter.Filter;
import ch.qos.logback.core.spi.FilterReply;

public class LogbackFilter extends Filter<ILoggingEvent> {
    @Override
    public FilterReply decide(ILoggingEvent event) {
        if(event.getMessage().contains("NO_LOG")) { // NO_LOG가 들어간 로그는 출력 안함
            return FilterReply.DENY;
        } else {
            return FilterReply.ACCEPT;
        }
    }
}

 

  • 제외 할 쿼리에 /* NO_LOG */ 추가
  • main/resources/mybatis/auth/UserMapper.xml 수정
<select id="getUser" parameterType="String" resultType="User">
    /* NO_LOG */
    SELECT  user_id
        ,   user_password AS password
        ,   user_name
    FROM    tcm_user_mst
    WHERE   user_id     =   #{value}
</select>

 

  • main/resources/logback-spring.xml 수정
<filter class="com.wise.config.LogbackFilter"/>

Spring Boot 구축_107.png

Spring Boot 구축_108.png

 

인터셉터 설정

LoggerInterceptor.java 생성 및 작성

  • main/java/com/wise/interceptor/LoggerInterceptor.java

    Spring Boot 구축_25.png

package com.wise.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

@Slf4j
public class LoggerInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 컨트롤러 실행되기 전 수행
		log.info("[[[ START {} ]]]", request.getRequestURI());
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // 컨트롤러 실행된 후 수행
        log.info("[[[ END {} ]]] \n", request.getRequestURI());
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception {
        // 뷰 응답 완료 후 수행

    }
}

 

interceptor 등록

  • main/java/com/wise/config/WebMvcConfiguration.java 생성

    Spring Boot 구축_40.png

package com.wise.config;

import com.wise.interceptor.LoggerInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry){
        // LoggerInterceptor 등록
        registry.addInterceptor(new LoggerInterceptor());
    }
}

 

로그 출력 확인

Spring Boot 구축_41.png

 

AOP 설정

Dependency 추가

  • build.gradle 수정
implementation 'org.springframework.boot:spring-boot-starter-aop'

Spring Boot 구축_42.png

 

LoggerAspect.java 생성 및 작성

  • main/java/com/wise/aop/LoggerAspect.java

    Spring Boot 구축_43.png

package com.wise.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Aspect
public class LoggerAspect {

    /**
     * Around : 대상 메소드의 호출 전후, 예외 발생 등 모든 시점에 적용할 수 있는 어드바이스를 정의합니다. 가장 범용적으로 사용할 수 있는 어드바이스입니다.
     * Before : 대상 메소드가 실행되기 전에 적용할 어드바이스를 정의합니다.
     * AfterReturning : 대상 메소드가 성공적으로 실행되고 결과값을 반환한 후 적용할 어드바이스를 정의합니다.
     * AfterThrowing : 대상 메소드에서 예외가 발생했을 때 적용할 어드바이스를 정의합니다. try/catch문의 catch와 비슷한 역할을 합니다.
     * After : 대상 메소드의 정상적인 수행 여부와 상관없이 무조건 실행되는 어드바이스를 정의합니다. 즉, 예외가 발생하더라도 실행되기 때문에 자바의 finally와 비슷한 역할을 합니다.
     */
    @Around("execution(* com.wise..*Controller.*(..)) or execution(* com.wise..*Service.*(..)) or execution(* com.wise..*DAO.*(..))")
    public Object logPrint(ProceedingJoinPoint joinPoint) throws Throwable {
        String type = "";
        String name = joinPoint.getSignature().getDeclaringTypeName();
        if (name.indexOf("Controller") > -1) {
            type = "Controller  \t:  ";
        }
        else if (name.indexOf("Service") > -1) {
            type = "Service  \t\t:  ";
        }
        else if (name.indexOf("DAO") > -1) {
            type = "DAO  \t\t:  ";
        }
        log.info(type + name + "." + joinPoint.getSignature().getName() + "()");
        return joinPoint.proceed();
    }
}

 

로그 출력 확인

Spring Boot 구축_44.png

 

Controller의 Request, Response 공통 처리

Request 공통 처리

  • main/java/com/wise/aop/ReqAdviceController.java 생성
// 추가 부분
var mapBody = (Map<String, Object>) body;
mapBody.put("advice", "request");
log.info("Request Body: {}", mapBody);
package com.wise.aop;

import org.springframework.core.MethodParameter;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice;

import java.io.IOException;
import java.lang.reflect.Type;
import java.util.Map;

@Slf4j
@ControllerAdvice
public class ReqAdviceController implements RequestBodyAdvice {

    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType,
                            Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
                                           Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        // TODO Auto-generated method stub
        return inputMessage;
    }

    @SuppressWarnings("unchecked")
    @Override
    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
                                Class<? extends HttpMessageConverter<?>> converterType) {
        var mapBody = (Map<String, Object>) body;
        // mapBody.put("advice", "request");
        log.info("Request Body: {}", mapBody);
        return mapBody;
    }

    @Override
    public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
                                  Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        // TODO Auto-generated method stub
        return body;
    }

}

 

Response 공통 처리

  • main/java/com/wise/aop/RspAdviceController.java 생성
// 추가 부분
if (body instanceof Map) {
    var mapBody = (Map<String, Object>) body;
    mapBody.put("advice", "response");
    return mapBody;
}
package com.wise.aop;

import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

import java.util.Map;

@ControllerAdvice
public class RspAdviceController implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

    @SuppressWarnings("unchecked")
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
                                  ServerHttpResponse response) {
        if (body instanceof Map) {
            var mapBody = (Map<String, Object>) body;
            mapBody.put("advice", "response");
            return mapBody;
        }
        
        return body;
    }
}

 

공통 예외처리 (@RestControllerAdvice)

  • 예외 발생 시 화면이 아닌 JSON으로 응답

 

GlobalExceptionHandler.java 생성 및 작성

  • main/java/com/wise/aop/GlobalExceptionHandler.java
package com.wise.aop;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 상세한 Exception은 아래 메소드보다 위에 작성해야한다.

    // Default Exception
    @ExceptionHandler(Exception.class)
    public Map<String, Object> defaultExceptionHandler(HttpServletRequest request, Exception exception) {
        Map<String, Object> result = new HashMap<>();
        result.put("ERR_CODE", "100");
        result.put("ERR_MSG", exception.getMessage());
        log.error("Exception", exception);
        return result;
    }
}
/*
예외 발생 시 화면으로 표시하고 싶은 경우 아래 사용
@ControllerAdvice
public class ExceptionHandler {

    // 상세한 Exception은 아래 메소드보다 위에 작성해야한다.

    // Default Exception
    @ExceptionHandler(Exception.class)
    public String defaultExceptionHandler(HttpServletRequest request, Exception exception) {
        log.error("Exception", exception);
        return "error/error_default";
    }
}
*/

 

에러 발생

  • TestController.java

Spring Boot 구축_60.png

Spring Boot 구축_61.png

Spring Boot 구축_62.png

 

사용자 정의 예외처리

CustomException.java 생성 및 작성

  • main/java/com/wise/aop/CustomException.java
package com.wise.aop;

public class CustomException extends RuntimeException {
    private final int ERR_CODE;

    public CustomException(String msg){
        super(msg);
        ERR_CODE = 100;
    }

    public CustomException(String msg, int errCode){
        super(msg);
        ERR_CODE = errCode;
    }

    public int getErrCode() {
        return ERR_CODE;
    }
}

 

GlobalExceptionHandler.java 수정

@ExceptionHandler(CustomException.class)
public Map<String, Object> customExceptionHandler(HttpServletRequest request, CustomException ce) {
    Map<String, Object> result = new HashMap<>();
    result.put("ERR_CODE", ce.getErrCode());
    result.put("ERR_MSG", ce.getMessage());
    log.error(String.valueOf(ce.getErrCode()));
    log.error(ce.getMessage());
    return result;
}

Spring Boot 구축_63.png

 

에러 발생

throw new CustomException("Test Custom Exception");

Spring Boot 구축_64.png

Spring Boot 구축_65.png

Spring Boot 구축_66.png

 

http 통신 error 페이지 이동 처리

error_default.html 생성 및 작성

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Error</title>
</head>
<body>
<h1>Error</h1>
</body>
</html>

 

error_auth.html 생성 및 작성

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Error</title>
</head>
<body>
<h1>권한이 없습니다.</h1>
</body>
</html>

 

CustomErrorController.java 생성 및 작성

  • main/java/com/wise/auth/CustomErrorController.java
package com.wise.auth;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class CustomErrorController implements ErrorController {

    // View 요청 시
    @RequestMapping(value = "/error", produces = MediaType.TEXT_HTML_VALUE)
    public String errorHtml(HttpServletRequest request) {
        Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);

        if(status != null){
            int statusCode = Integer.valueOf(status.toString());

            if(statusCode == HttpStatus.NOT_FOUND.value()) {
                return "error/error_default";
            }

            // 권한 없을 경우
            if(statusCode == HttpStatus.FORBIDDEN.value() || statusCode == HttpStatus.UNAUTHORIZED.value()) {
                //return "redirect:/login";
                return "error/error_auth";
            }
        }
        return "error/error_default";
    }

    // AJAX 요청 시
    @RequestMapping("/error")
    public ResponseEntity<Void> error(HttpServletRequest request) {
        String status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE).toString();

        return new ResponseEntity<Void>(HttpStatus.valueOf(Integer.parseInt(status)));
    }
}

 

트랜잭션 설정

DatabaseConfiguration.java 수정

@EnableTransactionManagement

...

@Bean
public PlatformTransactionManager transactionManager() throws Exception {
    return new DataSourceTransactionManager(dataSource());
}

Spring Boot 구축_45.png

 

적용

  • TestController.java

    Spring Boot 구축_46.png

 

로그 출력 확인

Spring Boot 구축_47.png

 

Service-Mybatis 방식 도입

Info
  • 스프링 기본 MVC 구조 Controller - Service- DAO - Mybatis가 아닌 Service-Mybatis 구조 채택 (밸류체인. 롯데 방식)
  • 하나의 컨트롤러로 호출

 

CommonMap.java 생성 및 작성

  • main/java/com/wise/module/common/util/CommonMap.java
package com.wise.module.common.util;

import java.util.HashMap;

public class CommonMap extends HashMap<String, Object> {
    public String getString(Object key) {
        Object value = super.get(key);
        return (value != null) ? String.valueOf(value) : null;
    }
}

 

ResultMap.java 생성 및 작성

  • main/java/com/wise/module/common/util/ResultMap.java
  • Mybatis xml에서 alias를 자동으로 camel case로 변환하기 위함
package com.wise.module.common.util;

import org.springframework.jdbc.support.JdbcUtils;

import java.util.HashMap;

public class ResultMap extends HashMap<String, Object> {
    @Override
    public Object put(String key, Object value) {
        // 결과 값을 Camel Case로 변환
        return super.put(JdbcUtils.convertUnderscoreNameToPropertyName(key), value);
    }
}

 

BeanUtil.java 생성 및 작성

  • main/java/com/wise/module/common/util/BeanUtil.java
package com.wise.module.common.util;

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Component
public class BeanUtil implements ApplicationContextAware {

    private static ApplicationContext context;

    @Override
    public void setApplicationContext(ApplicationContext context) throws BeansException {
        BeanUtil.context = context;
    }

    public static Object getBean(String sBeanName) {
        return BeanUtil.context.getBean(sBeanName);
    }

    public static Method getMethod(Object bean, String methodName) {
        Method[] methods = bean.getClass().getMethods();

        for (int i = 0; i < methods.length; i++) {
            if (methods[i].getName().equals(methodName)) {
                return methods[i];
            }
        }

        throw new RuntimeException(methodName + " is not exist.");
    }
}

 

공통 service 호출 Controller 생성 및 작성

  • main/java/com/wise/module/common/controller/ServiceController.java
package com.wise.module.common.controller;

import com.wise.aop.CustomException;
import com.wise.module.common.util.BeanUtil;
import com.wise.module.common.util.CommonMap;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;

@CrossOrigin
@RestController
@RequestMapping("/api")
public class ServiceController {
    @PostMapping("/{service}/{method}")
    public Map<String, Object> svcCtr(
            @RequestBody CommonMap params,
            HttpServletRequest req,
            HttpServletResponse res,
            @PathVariable("service") String service,
            @PathVariable("method") String method) throws Exception {

        var result = new CommonMap();

        if("null".equals(method)) return result;

        Object bean = BeanUtil.getBean(service.concat("Service"));
        Method action = BeanUtil.getMethod(bean, method);

        try {
            result = (CommonMap) action.invoke(bean, params, req, res);
        }catch(InvocationTargetException ite) {
            if (ite.getCause() instanceof CustomException) {
                CustomException ce = (CustomException) ite.getCause();
                throw new CustomException(ce.getMessage(), ce.getErrCode());
            } else {
                Exception e = (Exception) ite.getCause();
                throw new Exception(e.getMessage());
            }
        }

        return result;
    }
}

 

공통 DAO 생성 및 작성

  • main/java/com/wise/module/common/dao/CommonDAO.java
package com.wise.module.common.dao;

import com.wise.module.common.util.CommonMap;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public class CommonDAO {
    @Autowired
    private SqlSessionTemplate sqlSession;

    public List<CommonMap> selectList(String sqlId, CommonMap params) {
        return sqlSession.selectList(sqlId, params);
    }

    public CommonMap selectOne(String sqlId, CommonMap params) {
        return sqlSession.selectOne(sqlId, params);
    }

    public String selectString(String sqlId, CommonMap params) {
        return sqlSession.selectOne(sqlId, params);
    }

    public int selectInt(String sqlId, CommonMap params) {
        return sqlSession.selectOne(sqlId, params);
    }

    public int insert(String sqlId, CommonMap params) {
        return sqlSession.insert(sqlId, params);
    }

    public int update(String sqlId, CommonMap params) {
        return sqlSession.update(sqlId, params);
    }

    public int delete(String sqlId, Optional<CommonMap> params) {
        return sqlSession.delete(sqlId, params);
    }
}

 

공통 Service 생성 및 작성

  • main/java/com/wise/module/common/service/CommonService.java
package com.wise.module.common.service;

import com.wise.module.common.dao.CommonDAO;
import org.springframework.beans.factory.annotation.Autowired;

public class CommonService {
    @Autowired
    protected CommonDAO commonDAO;
}

 

조회 테스트 Service 생성 및 작성

  • main/java/com/wise/module/adm/AdminTestService.java
package com.wise.module.adm;

import com.wise.module.common.service.CommonService;
import com.wise.module.common.util.CommonMap;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RequestBody;

@Service
public class AdminTestService extends CommonService {
    public CommonMap selectAdminList(@RequestBody CommonMap params, HttpServletRequest req, HttpServletResponse res) {
        params.put("param1", "AAA");
        var list = commonDAO.selectList("test.Test.selectTest", params);

        var result = new CommonMap();
        result.put("list", list);
        return result;
    }
}

Spring Boot 구축_67.png

 

TestMapper 수정

  • main/resources/mybatis/test/TestMapper.xml
  • 기존 resultType -> 변경 "ResultMap"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="test.Test">
    <select id="selectTest" parameterType="HashMap" resultType="ResultMap">
        SELECT  user_id  
            ,   user_name  
            ,   #{param1}  
        FROM    tcm_user_mst
    </select>
</mapper>

 

ajax 테스트

  • main/resources/templates/sample.html 수정
fetch('http://localhost:8080/api/adminTest/selectAdminList', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json;charset=UTF-8',
    },
    body: JSON.stringify({})
});

Spring Boot 구축_68.png

  • http://localhost:8080/v/sample

    Spring Boot 구축_14.png

Spring Boot 구축_69.png

 

Spring Security 설정 및 JWT 로그인

 

  • 보편적으로 Access Token과 Refresh Token을 사용
  • Refresh Token 대신 새로운 방식인 UUID 비교 방식 적용

 

build.gradle 수정

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

Spring Boot 구축_105.png

 

application.yml 수정

security:
  jwt:
    access:
      token:
        secret-key: access-token-key-wise-ver-2-2024 # JWT Access Token
        expire: 86400000 # 24 Hour (60*60*24*1000)

Spring Boot 구축_106.png

 

User.java

  • main/java/com/wise/auth/User.java
package com.wise.auth;

public record User(
        String userId,
        String password
) {}

 

UserDAO.java

  • main/java/com/wise/auth/UserDAO.java
package com.wise.auth;

import com.wise.module.common.util.CommonMap;
import lombok.RequiredArgsConstructor;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.stereotype.Repository;

@RequiredArgsConstructor
@Repository
public class UserDAO {
    private final SqlSessionTemplate sqlSession;

    public User getUser(String username) {
        return sqlSession.selectOne("auth.User.getUser", username);
    }

    public int insertUser(CommonMap param) {
        return sqlSession.insert("auth.User.insertUser", param);
    }
}

 

UserMapper.xml

  • main/resources/mybatis/auth/UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="auth.User">
    <select id="getUser" parameterType="String" resultType="User">
        SELECT  user_id
            ,   user_password AS password
            ,   user_name
        FROM    tcm_user_mst
        WHERE   user_id     =   #{value}
    </select>

    <select id="getMaxPersonNoAndUserSid" resultType="ResultMap">
        SELECT  MAX(person_no) AS person_no
            ,   MAX(user_sid) AS user_sid
        FROM    tcm_user_mst
    </select>

    <insert id="insertUser" parameterType="HashMap">
        INSERT INTO tcm_user_mst
            (
                    user_id
                ,   user_password
                ,   person_no
                ,   user_sid
                ,   user_name
                ,   company_code
                ,   department_code
            )
        VALUES
            (
                    #{userId}
                ,   #{password}
                ,   #{personNo}
                ,   #{userSid}
                ,   #{userName}
                ,   #{companyCode}
                ,   #{departmentCode}
            )
    </insert>
</mapper>

 

CustomUserDetailsService.java

  • main/java/com/wise/auth/CustomUserDetailsService.java
package com.wise.auth;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;

@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserDAO userDAO;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user;
        
        try {
            user = userDAO.getUser(username);
        } catch (Exception e) {
            throw new UsernameNotFoundException("Invalid ID");
        }

        return new org.springframework.security.core.userdetails.User(user.userId(), user.password(), new ArrayList<>());
    }
}

 

JwtTokenProvider.java

  • main/java/com/wise/auth/JwtTokenProvider.java
package com.wise.auth;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.UUID;

@Getter
@Component
public class JwtTokenProvider {
    private final SecretKey secretKey;
    private final long expire;
    private final String cookieName;

    @Autowired
    private CustomUserDetailsService userDetailsService;

    public JwtTokenProvider(@Value("${security.jwt.access.token.secret-key}") String secretKey,
                            @Value("${security.jwt.access.token.expire}") long expire) {
        byte[] keyBytes = secretKey.getBytes();
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
        this.expire = expire;
        this.cookieName = "ACCESS_TOKEN";
    }

    // 토큰생성
    public String createToken(String subject) {
        Date now = new Date();
        Date validity = new Date(now.getTime() + expire);
        String identify = UUID.randomUUID().toString();

        return Jwts.builder()
                .subject(subject)                                   // 주제 설정
                .issuedAt(now)                                      // 토큰 발행 시간 설정
                .expiration(validity)                               // 만료 시간 설정
                .claim("Identify", identify)                        // 식별자 설정
                .signWith(this.secretKey)                           // 키를 사용하여 서명
                .compact();
    }

    // 유효한 토큰인지 확인
    public boolean validateToken(String token) {
        if(token == null) return false;

        try {
            Jwts.parser()
                    .verifyWith(this.secretKey)
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    // 유효한 identify인지 확인
    public boolean validateIdentify(String token, String identify) {
        if(token == null) return false;
        return identify.equals(getIdentify(token));
    }

    // 토큰에서 값 추출
    public String getSubject(String token) {
        return Jwts.parser()
                .verifyWith(this.secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload().getSubject();
    }

    // 토큰에서 identify claim 값 추출
    public String getIdentify(String token) {
        return Jwts.parser()
                .verifyWith(this.secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .get("Identify", String.class);
    }

    // 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token, HttpServletRequest request) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getSubject(token));

        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities());

        usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

        return usernamePasswordAuthenticationToken;
    }
}

 

JwtCookieUtil.java

  • main/java/com/wise/auth/JwtCookieUtil.java
package com.wise.auth;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class JwtCookieUtil {
    private final JwtTokenProvider jwtTokenProvider;

    public Cookie createJwtCookie(String cookieName, String value){
        Cookie token = new Cookie(cookieName, value);
        token.setHttpOnly(true);
        token.setMaxAge((int)jwtTokenProvider.getExpire());
        token.setPath("/");
        return token;
    }

    public Cookie createIdentifyCookie(String value){
        Cookie token = new Cookie("Identify", value);
        token.setHttpOnly(true);
        token.setPath("/");
        return token;
    }

    public Cookie getCookie(HttpServletRequest req, String cookieName){
        final Cookie[] cookies = req.getCookies();
        if(cookies == null) return null;
        for(Cookie cookie : cookies){
            if(cookie.getName().equals(cookieName))
                return cookie;
        }
        return null;
    }

    public Cookie destroyCookie(String cookieName) {
        Cookie token = new Cookie(cookieName,"");
        token.setHttpOnly(true);
        token.setMaxAge(0);
        token.setPath("/");
        return token;
    }
}

 

JwtFilter.java

  • main/java/com/wise/auth/JwtFilter.java
package com.wise.auth;

import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
@Component
public class JwtFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;
    private final JwtCookieUtil jwtCookieUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // 쿠키에서 토큰 받기
        final Cookie jwtCookie = jwtCookieUtil.getCookie(request, jwtTokenProvider.getCookieName());
        final Cookie identifyCookie = jwtCookieUtil.getCookie(request, "Identify");

        try {
            if(jwtCookie != null && identifyCookie != null) {
                String token = jwtCookie.getValue();
                String identify = identifyCookie.getValue();
                // 유효한 토큰인지 확인
                if ( jwtTokenProvider.validateToken(token) && jwtTokenProvider.validateIdentify(token, identify) ) {
                    // 토큰으로부터 유저 정보 받기
                    Authentication authentication = jwtTokenProvider.getAuthentication(token, request);
                    // SecurityContext 에 Authentication 객체를 저장합니다.
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        } catch (ExpiredJwtException e) {

        }

        filterChain.doFilter(request, response);
    }
}

 

WebSecurityConfiguration.java

  • main/java/com/wise/config/WebSecurityConfiguration.java
package com.wise.config;

import com.wise.auth.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration {
    private final JwtFilter jwtFilter;

    // 비밀번호 암호화 Bean 등록
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic(AbstractHttpConfigurer::disable)                                 // security에서 기본으로 생성하는 login페이지 사용 안함
                .csrf(AbstractHttpConfigurer::disable)                                      // csrf 보안 토큰 disable 처리
                .authorizeHttpRequests(requests -> { requests
                        .requestMatchers("/api/**", "/v/**").authenticated()        // /api, /v는 인증 필요
                        .anyRequest().permitAll();                                          // 그 외 요청은 인증 필요 없음
                })
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) // iframe 에러 해결
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);    // JwtFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다

        return http.build();
    }
}

 

AuthController.java

  • main/java/com/wise/auth/AuthController.java
package com.wise.auth;

import com.wise.aop.CustomException;
import com.wise.module.common.util.CommonMap;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;

@Slf4j
@RequiredArgsConstructor
@Controller
public class AuthController {
    private final JwtTokenProvider jwtTokenProvider;
    private final JwtCookieUtil jwtCookieUtil;
    private final PasswordEncoder passwordEncoder;
    private final UserDAO userDAO;

    public boolean validateToken(HttpServletRequest req) {
        // 쿠키에서 토큰 받기
        final Cookie jwtCookie = jwtCookieUtil.getCookie(req, jwtTokenProvider.getCookieName());
        final Cookie identifyCookie = jwtCookieUtil.getCookie(req, "Identify");

        if(jwtCookie != null && identifyCookie != null) {
            String token = jwtCookie.getValue();
            String identify = identifyCookie.getValue();
            // 유효한 토큰인지 확인
            if ( jwtTokenProvider.validateToken(token) && jwtTokenProvider.validateIdentify(token, identify) ) {
                return true;
            }
        }

        return false;
    }

    @GetMapping("/")
    public String index() {
        return "redirect:/login";
    }

    @GetMapping("/main")
    public String main(HttpServletRequest req, HttpServletResponse res) {
        if(!validateToken(req)) {
            return "redirect:/logOut";
        }

        return "forward:/main.html";
    }

    @GetMapping("/login")
    public String login(HttpServletRequest req, HttpServletResponse res) {
        if(validateToken(req)) {
            return "redirect:/main";
        }

        return "login";
    }

    @GetMapping("/logOut")
    public String logout(HttpServletRequest req, HttpServletResponse res) {
        Cookie destroyJwtCookie = jwtCookieUtil.destroyCookie(jwtTokenProvider.getCookieName());
        Cookie destroyIdentifyCookie = jwtCookieUtil.destroyCookie("Identify");
        res.addCookie(destroyJwtCookie);
        res.addCookie(destroyIdentifyCookie);

        return "redirect:/login";
    }

    @PostMapping("/auth")
    @ResponseBody
    public CommonMap auth(@RequestBody CommonMap params, HttpServletRequest req, HttpServletResponse res) {
        User member = userDAO.getUser(params.getString("userId"));
        if( member == null || !passwordEncoder.matches(params.getString("password"), member.password()) ) {
            throw new CustomException("Invalid ID/Password");
        }

        String token = jwtTokenProvider.createToken(member.userId());
        Cookie accessToken = jwtCookieUtil.createJwtCookie(jwtTokenProvider.getCookieName(), token);
        Cookie identify = jwtCookieUtil.createIdentifyCookie(jwtTokenProvider.getIdentify(token));
        res.addCookie(accessToken);
        res.addCookie(identify);

        return new CommonMap();
    }

    @PostMapping("/join")
    @ResponseBody
    public CommonMap join(@RequestBody CommonMap params, HttpServletRequest req, HttpServletResponse res) {
        if(ObjectUtils.isEmpty(params.get("userId")) || ObjectUtils.isEmpty(params.getString("password"))) {
            throw new CustomException("Input ID/PW");
        }

        User member = userDAO.getUser(params.getString("userId"));
        if(member != null) {
            throw new CustomException("Exist ID");
        } else {
            params.put("password", passwordEncoder.encode(params.getString("password")));
            userDAO.insertUser(params);
            String token = jwtTokenProvider.createToken(params.getString("userId"));
            Cookie accessToken = jwtCookieUtil.createJwtCookie(jwtTokenProvider.getCookieName(), token);
            Cookie identify = jwtCookieUtil.createIdentifyCookie(jwtTokenProvider.getIdentify(token));
            res.addCookie(accessToken);
            res.addCookie(identify);
        }

        return new CommonMap();
    }

}

 

login.html

  • main/resources/templates/login.html
<!DOCTYPE html>
<html>
<head>
  <meta charset='utf-8'>
  <meta http-equiv='X-UA-Compatible' content='IE=edge'>
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
  <title>Title</title>
</head>

<body>
  <h1>Login</h1>
  <div id="divId">
    ID : <input id="userId" type="text"><br>
    PW : <input id="password" type="text"><br>
    <br>
    <button id="btn" type="button">로그인</button>
    <button id="btn2" type="button">가입</button>
  </div>

  <script>
    document.addEventListener("DOMContentLoaded", () => {
      document.querySelector('#btn').addEventListener('click', (e) => {
        fetch('/auth', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json;charset=UTF-8',
          },
          body: JSON.stringify({
            userId: document.querySelector('#userId').value,
            password: document.querySelector('#password').value,
          }),
        }).then((response) => {
          if (response.ok) {
            return response.json();
          }
          throw new Error('Network response was not ok.');
        }).then((response) => {
          if(response.ERR_MSG) {
            console.error(response.ERR_MSG);
          } else {
            location.replace("/main");
          }
        }).catch((error) => {
          console.error('There has been a problem with your fetch operation:', error);
        });
      });
    });
  </script>
</body>
</html>

 

main.html

  • main/resources/templates/main.html
<!DOCTYPE html>
<html>
<head>
  <meta charset='utf-8'>
  <meta http-equiv='X-UA-Compatible' content='IE=edge'>
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
  <title>Main</title>
</head>

<body>
  <h1>Main</h1>
  <div id="divId">
    <button id="btn" type="button">로그아웃</button>
  </div>

  <script>
    document.addEventListener("DOMContentLoaded", () => {
      document.querySelector('#btn').addEventListener('click', (e) => {
        location.replace("/logOut");
      });
    });
  </script>
</body>
</html>

 

모니터링 구축

Windows 설치

프로메테우스

 

  • C:\Wise 에 압출 풀기

 

  • C:\Wise\prometheus-2.53.2\prometheus.yml 수정
  - job_name: "spring-actuator"
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['localhost:8080']

Spring Boot 구축_92.png

 

  • C:\Wise\prometheus-2.53.2\prometheus.exe 실행

    Spring Boot 구축_74.png

 

  • Spring Boot 구축_75.png

 

  • http://localhost:9090/ 접속

    Spring Boot 구축_76.png

 

그라파나

 

  • C:\Wise 에 압출 풀기

 

  • C:\Wise\grafana-v11.2.0\bin\grafana-server.exe 실행

    Spring Boot 구축_78.png

 

  • Spring Boot 구축_79.png

 

  • http://localhost:3000 접속

  • admin / admin

    Spring Boot 구축_80.png

 

  • Add your first data source 선택

    Spring Boot 구축_81.png

 

  • Prometheus 선택

    Spring Boot 구축_82.png

 

  • 프로메테우스 서버 주소 입력 ( http://localhost:9090 )

    Spring Boot 구축_83.png

 

  • 저장

    Spring Boot 구축_84.png

 

 

  • Copy ID to clipboard 선택

    Spring Boot 구축_86.png

 

  • http://localhost:3000 에서 Dashboard > New 버튼 > Import 선택

    Spring Boot 구축_87.png

 

  • 복사한 대시보드 ID 붙여넣기 후 Load 클릭

    Spring Boot 구축_88.png

 

  • Prometheus 선택 후 Import 클릭

    Spring Boot 구축_89.png

 

  • Dashboard 선택

    Spring Boot 구축_90.png

 

  • 완료

    Spring Boot 구축_91.png

 

로키

  • loki-local-config.yaml 다운로드 - exe 버전과 동일한 버전으로 다운로드
> cd C:\Wise\loki-3.0.1
> curl -O -L "https://raw.githubusercontent.com/grafana/loki/v3.0.1/cmd/loki/loki-local-config.yaml"

 

 

  • C:\Wise\loki-3.0.1 에 압축 풀기

 

  • 실행
> cd C:\Wise\loki-3.0.1
> loki-windows-amd64 -config.file=loki-local-config.yaml

Spring Boot 구축_97.png

 

프롬테일

  • C:\Wise 에 loki-3.0.1 폴더 생성

 

  • promtail-local-config.yaml 다운로드 - exe 버전과 동일한 버전으로 다운로드
> cd C:\Wise\loki-3.0.1
> curl -O -L "https://raw.githubusercontent.com/grafana/loki/v3.0.1/clients/cmd/promtail/promtail-local-config.yaml"

 

server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://127.0.0.1:3100/loki/api/v1/push # localhost -> 127.0.0.1

scrape_configs:
- job_name: system
  static_configs:
  - targets:
      - localhost
    labels:
      job: wiselogs
      __path__: C:\logs\info*.log # 로그 파일의 위치를 작성
      stream: stdout

 

Spring Boot 구축_95.png

 

  • C:\Wise\loki-3.0.1 에 압축 풀기

 

  • 실행
> cd C:\Wise\loki-3.0.1
> promtail-windows-amd64 -config.file=promtail-local-config.yaml

Spring Boot 구축_98.png

 

그라파나 로키 추가

  • 그라파나 접속 http://localhost:3000/

 

  • Loki 추가

    Spring Boot 구축_99.png

 

  • 로키 서버 주소 입력 ( http://localhost:3100 ) 후 저장

    Spring Boot 구축_100.png

 

  • Explore > Loki 선택

    Spring Boot 구축_101.png

 

  • promtail-local-config.yaml 에서 설정한 job 선택

    Spring Boot 구축_102.png

 

  • Time은 수집시간이므로 off

  • Line contains에서 문자열 검색

    Spring Boot 구축_104.png

 

  • Run query 버튼 클릭

    Spring Boot 구축_103.png

 

html, js 수정 시 빌드 없이 즉시 반영 설정

  • application-local.yml 수정
spring:
  devtools:
    livereload:
      enabled: true
    restart:
      enabled: false
    thyemleaf:
      cache: false

Spring Boot 구축_109.png